15장. 메서드
14장에서 포인터를 익혔다. 이제 한 발 더 나가서, 특정 타입에 “동작” 을 묶어 두는 방법을 배운다.
함수와 메서드의 경계는 한 줄 차이지만, 이 한 줄이 Go 의 객체 지향 스타일을 만든다.
목표:
- 메서드와 함수의 차이를 이해한다
- 값 리시버와 포인터 리시버를 구분한다
- 어느 쪽을 언제 쓸지 판단할 수 있다
- 사용자 정의 타입에 메서드를 붙여 본다
15.1 메서드란
메서드는 “특정 타입에 묶여 있는 함수” 다.
13장에서 만든 Person 구조체를 다시 가져와 보자.
type Person struct {
Name string
Age int
}
이름을 출력하는 동작이 필요하다고 하자. 함수로 만들면 이렇게 된다.
func Greet(p Person) {
fmt.Println("안녕,", p.Name)
}
Greet(p)
메서드로 만들면 이렇게 된다.
func (p Person) Greet() {
fmt.Println("안녕,", p.Name)
}
p.Greet()
호출이 Greet(p) 에서 p.Greet() 로 바뀌었다.
“인사하는 동작은 Person 에 속한다” 는 의도가
문법에 그대로 드러난다.
메서드와 함수의 차이
| 항목 | 함수 | 메서드 |
|---|---|---|
| 정의 위치 | 어디든 | 타입에 묶임 |
| 호출 형태 | f(x) | x.f() |
| 첫 인자 | 일반 매개변수 | 리시버 (receiver) |
본질적으로는 메서드도 함수다. 단지 “어떤 타입의 동작인지” 를 문법으로 표현한 것뿐이다.
15.2 메서드 정의
메서드 정의 문법은 다음과 같다.
func (리시버변수 리시버타입) 메서드이름(매개변수) 반환타입 {
// 본문
}
func 와 메서드 이름 사이에
괄호로 묶인 한 덩어리가 추가됐다.
이걸 리시버(receiver) 라고 부른다.
간단한 예제
type Rectangle struct {
Width, Height float64
}
// Rectangle 에 Area 메서드를 정의
func (r Rectangle) Area() float64 {
return r.Width * r.Height
}
사용은 이렇게 한다.
r := Rectangle{Width: 3, Height: 4}
fmt.Println(r.Area()) // 12
리시버 이름 짓는 관례
리시버 변수 이름은 보통 짧게 짓는다.
- 타입 이름의 첫 글자 한두 개
Rectangle→rPerson→pHttpServer→s또는srv
Go 코드에서 this, self 같은 이름은 거의 안 쓴다.
메서드 이름 규칙
함수 / 변수와 같은 규칙이 적용된다.
- 대문자로 시작 → 패키지 바깥에 공개됨 (exported)
- 소문자로 시작 → 패키지 안에서만 사용
공개 / 비공개 규칙은 20장에서 더 자세히 다룬다.
15.3 값 리시버 vs 포인터 리시버
리시버는 두 가지 형태가 있다.
func (r Rectangle) Area() float64 { ... } // 값 리시버
func (r *Rectangle) Scale(factor float64) { ... } // 포인터 리시버
* 가 하나 붙는 작은 차이다.
하지만 동작은 14장에서 본 값/포인터 차이 그대로다.
값 리시버는 복사본을 받는다
type Counter struct {
count int
}
func (c Counter) Add() {
c.count++ // 복사본을 1 증가
}
func main() {
c := Counter{}
c.Add()
c.Add()
c.Add()
fmt.Println(c.count) // 0
}
c.Add() 가 호출될 때마다
Counter 가 통째로 복사된다.
복사본을 늘려 봤자 원본은 그대로다.
포인터 리시버는 원본을 받는다
func (c *Counter) Add() {
c.count++ // 원본을 1 증가
}
func main() {
c := Counter{}
c.Add()
c.Add()
c.Add()
fmt.Println(c.count) // 3
}
*Counter 로 받았기 때문에
c.count++ 는 진짜 원본에 작용한다.
호출 문법은 똑같다
값이든 포인터든 호출은 똑같이 점으로 한다.
c := Counter{}
c.Add() // 포인터 리시버라도 (&c).Add() 처럼 안 써도 됨
Go 가 자동으로 주소를 잡아 준다. 포인터로 갖고 있어도 마찬가지다.
p := &Counter{}
p.Add() // OK
호출 시점에 “값을 메서드로 보낼 수 있는가” 만 보면 된다. 변수가 있고 주소를 잡을 수 있다면 Go 컴파일러가 알아서 변환한다.
값 리시버와 포인터 리시버 한눈에
| 항목 | 값 리시버 (r T) | 포인터 리시버 (r *T) |
|---|---|---|
| 받는 것 | 복사본 | 원본의 주소 |
| 원본 수정 | 불가능 | 가능 |
| 큰 구조체 비용 | 매번 복사 | 주소만 전달 |
| nil 가능성 | 없음 (값) | 있음 (*T) |
15.4 어느 쪽을 쓸지 판단하기
처음에는 헷갈리지만 규칙이 단순하다.
1. 메서드가 값을 수정해야 하면 포인터
func (c *Counter) Add() { c.count++ }
값 리시버로는 어차피 안 바뀐다. 초보가 가장 자주 만나는 함정이다.
2. 큰 구조체면 포인터
type Report struct {
Lines [10000]string
}
func (r *Report) Summary() string { ... }
매 호출마다 만 줄짜리 배열을 복사하지 않으려면 포인터 리시버를 쓴다.
3. 작고 불변이면 값
type Point struct {
X, Y int
}
func (p Point) Distance() float64 { ... }
Point 같이 두세 필드짜리 구조체는
복사 비용이 거의 없다.
값 리시버가 더 안전하다 (변경 의도가 없음을 코드가 말해 준다).
4. 한 타입의 메서드는 하나로 통일하라
이게 가장 중요한 관례다.
// 권장하지 않음 — 섞여 있다
func (p Person) String() string { ... }
func (p *Person) SetAge(a int) { ... }
값과 포인터를 섞으면 사용하는 쪽에서 헷갈린다. 또 16장의 인터페이스를 만족할 때 메서드 셋(method set) 규칙이 까다로워진다.
어느 한 메서드가 수정해야 한다면 모든 메서드를 포인터 리시버로 통일하는 게 일반적이다.
결정 흐름
| 조건 | 결정 |
|---|---|
| 값을 수정해야 함 | 포인터 |
| 구조체가 큼 | 포인터 |
| 작고 안 바뀜 | 값 |
| 같은 타입의 다른 메서드가 포인터 | 포인터 (통일) |
15.5 사용자 정의 타입에 메서드 붙이기
메서드는 구조체에만 붙는 게 아니다.
기본 타입에 별명 짓기
type Celsius float64
c := Celsius(36.5)
Celsius 는 float64 와 다른 타입이다.
값은 똑같이 들어가지만, 컴파일러가 구분해서 본다.
거기에 메서드 붙이기
type Celsius float64
func (c Celsius) ToFahrenheit() Celsius {
return c*9/5 + 32
}
func main() {
body := Celsius(36.5)
fmt.Println(body.ToFahrenheit()) // 97.7
}
float64 위에 의미를 한 겹 입힌 셈이다.
온도 단위 변환 같은 동작이 자연스럽게 묶인다.
슬라이스에도 가능하다
type Names []string
func (n Names) Count() int {
return len(n)
}
func main() {
list := Names{"가", "나", "다"}
fmt.Println(list.Count()) // 3
}
단, 같은 패키지 안에서만
Go 는 “남의 패키지의 타입에 내 패키지에서 메서드를 붙이는 행위” 를 금지한다.
// 금지! 컴파일 에러
func (s string) Shout() string {
return strings.ToUpper(s) + "!"
}
string 은 내장 타입이라
내가 메서드를 새로 정의할 수 없다.
필요하면 새 타입을 정의해야 한다.
type Shoutable string
func (s Shoutable) Shout() string {
return strings.ToUpper(string(s)) + "!"
}
이 규칙은 “남이 만든 타입의 동작을 내가 마음대로 바꾸지 못하게” 하는 안전장치다.
15.6 정리
이 장에서 살펴본 내용:
- 메서드는 특정 타입에 묶인 함수다
- 호출은
x.f()형태로 한다 - 리시버에는 값 리시버와 포인터 리시버가 있다
- 원본을 바꿔야 하면 포인터 리시버
- 큰 구조체도 포인터 리시버 (복사 비용)
- 작고 불변이면 값 리시버
- 한 타입의 메서드는 하나로 통일하는 게 관례
- 사용자 정의 타입(
type MyInt int)에도 메서드를 붙일 수 있다 - 다른 패키지의 타입에 메서드를 붙이는 건 금지
메서드까지 익히면 “동작을 가진 타입” 을 만들 수 있게 된다.
다음 장에서는 한 단계 더 추상화한다. “이런 메서드를 가진 모든 타입” 을 하나로 묶어 다루는 도구, 인터페이스를 배운다. Go 가 자랑하는 다형성이 여기서 등장한다.